winbrew_app\operations\update/
mod.rs

1//! Catalog refresh workflow for the CLI.
2//!
3//! # Overview
4//!
5//! The refresh workflow is API-driven and runs in four phases:
6//!
7//! 1. Preparation: ensure the catalog directories exist and clear stale temp files.
8//! 2. Selection: load local metadata if present, query the update API, and turn the
9//!    response into a `current`, `patch`, or `full` plan.
10//! 3. Execution: current plans return immediately, full plans download and verify a
11//!    full snapshot, and patch plans apply incremental SQL patches to a working copy.
12//! 4. Finalization: atomically rename the refreshed temp files into place and clean
13//!    up any leftover temporary artifacts.
14//!
15//! # API Surface
16//!
17//! - `refresh_catalog` is the production entry point used by the CLI and targets the
18//!   default update API.
19//! - `refresh_catalog_with_api_url` is a doc-hidden test hook that lets integration
20//!   tests point the workflow at a mock server.
21//! - `api` builds safe update URLs and fetches the update selection payload.
22//! - `planner` converts the selection response plus local metadata into a concrete
23//!   `CatalogDownloadPlan`.
24//! - `download` handles the full snapshot download, decompression, and final hash
25//!   verification.
26//! - `patch` applies incremental SQL patches against a working copy and writes the
27//!   refreshed metadata.
28//! - `metadata` loads local metadata, derives metadata URLs, and validates hashes.
29//!
30//! # Fallback Behavior
31//!
32//! If a patch application fails, the workflow clears the temp files, re-queries the
33//! API for a full snapshot plan, and retries through the snapshot path.
34//!
35//! # Cleanup
36//!
37//! Temporary files are removed on both success and failure. The final catalog and
38//! metadata files are only replaced after the new versions have been fully built.
39//!
40//! # Concurrency
41//!
42//! This module does not take a file lock. If multiple CLI processes can target the
43//! same catalog root concurrently, that lock belongs at a higher layer.
44
45mod api;
46mod download;
47mod metadata;
48mod patch;
49mod planner;
50mod types;
51
52use anyhow::{Context, Result, bail};
53use std::path::Path;
54
55use self::types::CatalogDownloadPlan;
56
57use crate::core::fs::{cleanup_path, finalize_temp_file};
58use crate::core::network::{Client, build_client};
59use crate::core::paths::ResolvedPaths;
60
61const CATALOG_UPDATE_API_URL: &str = "https://api.winbrew.dev/v1/update";
62
63/// Refreshes the local catalog using the default update API endpoint.
64///
65/// This is the production entry point used by the CLI. It delegates to the
66/// injected-URL helper so tests can exercise the same workflow against a mock
67/// server.
68pub fn refresh_catalog<FStart, FProgress>(
69    paths: &ResolvedPaths,
70    on_start: FStart,
71    on_progress: FProgress,
72) -> Result<()>
73where
74    FStart: FnOnce(Option<u64>),
75    FProgress: FnMut(u64),
76{
77    refresh_catalog_with_api_url(paths, CATALOG_UPDATE_API_URL, on_start, on_progress)
78}
79
80/// Refreshes the local catalog using a caller-provided update API URL.
81///
82/// This is the same workflow as [`refresh_catalog`], but it keeps the API URL
83/// injectable so integration tests can point the refresh logic at a mock
84/// server. The function stays public for that reason, but it is hidden from the
85/// generated API docs because it is not part of the intended CLI surface.
86#[doc(hidden)]
87pub fn refresh_catalog_with_api_url<FStart, FProgress>(
88    paths: &ResolvedPaths,
89    update_api_url: &str,
90    on_start: FStart,
91    on_progress: FProgress,
92) -> Result<()>
93where
94    FStart: FnOnce(Option<u64>),
95    FProgress: FnMut(u64),
96{
97    let catalog_path = paths.catalog_db.clone();
98    let catalog_dir = catalog_path
99        .parent()
100        .context("failed to resolve catalog database directory")?;
101
102    let catalog_temp_path = catalog_dir.join("catalog.db.download");
103    let metadata_temp_path = catalog_dir.join("metadata.json.download");
104    let metadata_path = catalog_dir.join("metadata.json");
105
106    let result = (|| -> Result<()> {
107        clear_temp_file(&catalog_temp_path)?;
108        clear_temp_file(&metadata_temp_path)?;
109
110        let client = build_client("winbrew-catalog-downloader")?;
111        let local_metadata = metadata::load_local_catalog_metadata(&metadata_path)?;
112
113        let selection =
114            api::fetch_catalog_update_selection(&client, update_api_url, local_metadata.as_ref())?;
115        let download_plan =
116            match planner::plan_catalog_download(local_metadata.as_ref(), selection)? {
117                Some(plan) => plan,
118                None => request_full_snapshot_plan(&client, update_api_url)?,
119            };
120
121        match &download_plan {
122            CatalogDownloadPlan::Current {
123                current_hash,
124                target_hash,
125            } => {
126                if current_hash != target_hash {
127                    tracing::warn!(current_hash = %current_hash, target_hash = %target_hash, "update worker reported a current plan with mismatched hashes");
128                }
129
130                return Ok(());
131            }
132            CatalogDownloadPlan::Full { .. } => {
133                download::download_catalog_release(
134                    &client,
135                    &download_plan,
136                    &catalog_temp_path,
137                    &metadata_temp_path,
138                    on_start,
139                    on_progress,
140                )?;
141            }
142            CatalogDownloadPlan::Patch {
143                patch_urls,
144                expected_hash,
145            } => {
146                let previous_metadata = local_metadata
147                    .as_ref()
148                    .context("patch updates require local catalog metadata")?;
149
150                if let Err(err) = patch::apply_catalog_patch_release(
151                    &client,
152                    &catalog_path,
153                    &catalog_temp_path,
154                    &metadata_temp_path,
155                    patch_urls,
156                    expected_hash,
157                    previous_metadata.current_hash.as_str(),
158                ) {
159                    tracing::warn!(error = %err, "patch catalog update failed; falling back to full snapshot");
160                    clear_temp_file(&catalog_temp_path)?;
161                    clear_temp_file(&metadata_temp_path)?;
162
163                    let fallback_plan = request_full_snapshot_plan(&client, update_api_url)?;
164                    download::download_catalog_release(
165                        &client,
166                        &fallback_plan,
167                        &catalog_temp_path,
168                        &metadata_temp_path,
169                        on_start,
170                        on_progress,
171                    )?;
172                }
173            }
174        }
175
176        finalize_temp_file(&catalog_temp_path, &catalog_path)?;
177        finalize_temp_file(&metadata_temp_path, &metadata_path)?;
178
179        Ok(())
180    })();
181
182    let _ = cleanup_path(&catalog_temp_path);
183    let _ = cleanup_path(&metadata_temp_path);
184
185    result
186}
187
188fn request_full_snapshot_plan(
189    client: &Client,
190    update_api_url: &str,
191) -> Result<CatalogDownloadPlan> {
192    let selection = api::fetch_full_snapshot_update_selection(client, update_api_url)?;
193
194    match planner::plan_catalog_download(None, selection)? {
195        Some(plan @ CatalogDownloadPlan::Full { .. }) => Ok(plan),
196        _ => bail!("update API did not return a full snapshot plan"),
197    }
198}
199
200fn clear_temp_file(path: &Path) -> Result<()> {
201    cleanup_path(path).context("failed to clear previous catalog download")
202}